Klasyfikacja gatunku za pomocą metadanych Spotify

Author

Tymoteusz Romanowicz

Wstęp

Spotify to szwedzki serwis strumieniowy oferujący dostęp do ponad 100 milionów utworów oraz 6 milionów podcastów. Posiada on oficjalne API, które umożliwia pobieranie metadanych opisujących treści portalu i tworzenie na ich podstawie aplikacji komunikujących się z portalem, takich jak system rekomendacji. Zmienne opisujące utwory muzyczne, które można wyodrębnić za jego pomocą, to między innymi energiczność, taneczność, czy tempo utworu, co pozwala pomyśleć, że można za ich pomocą sklasyfikować piosenkę do konkretnego gatunku. Żeby spróbować tego dokonać, postanowiłem podjąć próbę budowy optymalnego modelu klasyfikacji wieloklasowej, który na podstawie pozyskanych metadanych będzie przyporządkowywał utworowi jeden z gatunków muzycznych.

Sposób pozyskania danych

Wybór klas zmiennej zależnej

Na świecie istnieje szeroka gama gatunków muzycznych: od mongolskich śpiewów gardłowych, przez catstep aż po pirate metal. W swoim projekcie musiałem się jednak ograniczyć do zaledwie kilku, fundamentalnych gatunków, które dobrze uogólniałyby tę różnorodność.

Źródło: https://everynoise.com/

Źródło: https://everynoise.com/

Serwis FreeDB jest darmową bazą danych niegdyś służącą osobom wypalającym płyty CD do wstępnego uzupełniania informacji o utworach. Opisuje ona każdą znajdującą się tam piosenkę za pomocą jednego z następujących gatunków: blues, muzyka klasyczna, country, folk, jazz, newage, reggae, rock, soundtrack. Uznałem, że jest to całkiem kompletna lista podstawowych gatunków muzycznych. Pozwoliłem sobie jednak zamienić soundtrack i newage na hip-hop i metal, a następnie na jej podstawie określiłem stany, które będzie przyjmowała zmienna zależna w moim modelu. Ostatecznie więc, lista stanów, które będzie mogła przyjąć zmienna określająca gatunek, wygląda następująco:

  • blues,

  • muzyka klasyczna,

  • country,

  • folk,

  • jazz,

  • hip-hop,

  • reggae,

  • rock,

  • metal.

Stworzenie zbioru danych za pomocą API

API Spotify umożliwia jednoczesne pobranie danych dla wszystkich utworów z wybranego albumu, profilu artysty lub playlisty. W celu stworzenia zbioru danych wyszukałem więc w serwisie po kilka playlist zawierających utwory danego gatunku tak, żeby na jeden gatunek przypadało 200-300 obserwacji i zbiór był możliwie zrównoważony, a następnie wyniki złączyłem w jedną ramkę danych.

Code
Sys.setenv(SPOTIFY_CLIENT_ID = '8e358bf3471a4232babc75cfae3ffffc')
Sys.setenv(SPOTIFY_CLIENT_SECRET = '34b94c88be5a4eba98dad34d17b68f22')
Sys.setenv(SPOTIFY_REDIRECT_URI = 'http://localhost:3036')

playlist_names <- c(
  "Blues Classics", "Blues Standards", "Electric Blues Classics", "Classic Blues Guitar",
  "Classical Essentials", "Classical New Releases", 
  "Country's Greatest Hits", "Hot Country", "Country Top 50", 
  "Fresh Folk", "Roots Rising", 
  "jazz classics the best tunes in jazz history", 
  "Hip-Hop Drive", "Gold School", "RAP GENERACJA",
  "Reggae Classics", "Summer Sunshine Reggae", "celebrating the film bob marley one love", "Reggae Party", 
  "Rock Classics", "All New Rock",
  "Metal Essentials", "00s Metal Classics", "10s Metal Classics"
  )

pb <- txtProgressBar(min = 0,
                     max = length(playlist_names),
                     style = 3,
                     width = 50,
                     char = "=") 

dataset <- NULL

for (p in 1:length(playlist_names)) {
  
  search_results <- search_spotify(
    q = playlist_names[p], 
    type = c("playlist"), 
    authorization = get_spotify_access_token()
  )
  
  playlist_id <- search_results %>%  
    select(id, name, uri) %>% 
    slice_head(n = 1) %>% 
    select(id) %>% 
    pull()
  
  p_dt <- get_playlist_audio_features(
    username = "Spotify",
    playlist_uris = c(playlist_id), 
  )
  
  if (p == 1) {
    dataset <- p_dt
  }
  else {
    dataset <- rbind(dataset, p_dt)
  }

  setTxtProgressBar(pb, p)
}

df <- as.data.frame(dataset)
df <- df[,-which(sapply(df, class) == "list")]

# write.csv(df, "spotify-genres-classification.csv")
# write.csv(df, "spotify-genres-classification-new.csv")

Przetwarzanie i czyszczenie zbioru

Wstępna selekcja cech

Zbiór pierwotnie zawiera wiele niepotrzebnych zmiennych, takich jak identyfikator playlisty, z której pochodzi dany utwór czy link do okładki każdej piosenki.

X playlist_id playlist_name playlist_img playlist_owner_name playlist_owner_id danceability energy key loudness mode speechiness acousticness instrumentalness liveness valence tempo track.id analysis_url time_signature added_at is_local primary_color added_by.href added_by.id added_by.type added_by.uri added_by.external_urls.spotify track.preview_url track.explicit track.type track.episode track.track track.disc_number track.track_number track.duration_ms track.href track.name track.popularity track.uri track.is_local track.album.type track.album.album_type track.album.href track.album.id track.album.name track.album.release_date track.album.release_date_precision track.album.uri track.album.total_tracks track.album.external_urls.spotify track.external_ids.isrc track.external_urls.spotify video_thumbnail.url key_name mode_name key_mode
1 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.477 0.433 11 -6.473 0 0.0247 0.6890 0.000195 0.1510 0.611 82.520 1kPBT8S2wJFNAyBMnGVZgL https://api.spotify.com/v1/audio-analysis/1kPBT8S2wJFNAyBMnGVZgL 3 2020-01-15T21:29:46Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ NA FALSE track FALSE TRUE 1 2 156653 https://api.spotify.com/v1/tracks/1kPBT8S2wJFNAyBMnGVZgL I'd Rather Go Blind 77 spotify:track:1kPBT8S2wJFNAyBMnGVZgL FALSE album album https://api.spotify.com/v1/albums/4ReJ59T4YxC62WkfyVTWpr 4ReJ59T4YxC62WkfyVTWpr Tell Mama 1968-04-18 day spotify:album:4ReJ59T4YxC62WkfyVTWpr 12 https://open.spotify.com/album/4ReJ59T4YxC62WkfyVTWpr USMC16746346 https://open.spotify.com/track/1kPBT8S2wJFNAyBMnGVZgL NA B minor B minor
2 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.427 0.626 8 -11.821 1 0.0385 0.0274 0.003950 0.1060 0.743 126.877 1a2iF9XymafjRk56q7oCxo https://api.spotify.com/v1/audio-analysis/1a2iF9XymafjRk56q7oCxo 4 2018-03-29T19:44:07Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ https://p.scdn.co/mp3-preview/8faa93aa2d3e22a430ee9685e3ad2695c5508417?cid=8e358bf3471a4232babc75cfae3ffffc FALSE track FALSE TRUE 1 2 219586 https://api.spotify.com/v1/tracks/1a2iF9XymafjRk56q7oCxo Pride and Joy 67 spotify:track:1a2iF9XymafjRk56q7oCxo FALSE album album https://api.spotify.com/v1/albums/1AL5oXZRtTc8PyhcTwg4xQ 1AL5oXZRtTc8PyhcTwg4xQ Texas Flood (Legacy Edition) 1983 year spotify:album:1AL5oXZRtTc8PyhcTwg4xQ 20 https://open.spotify.com/album/1AL5oXZRtTc8PyhcTwg4xQ USSM10008869 https://open.spotify.com/track/1a2iF9XymafjRk56q7oCxo NA G# major G# major
3 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.518 0.376 9 -18.248 0 0.0431 0.9330 0.860000 0.0976 0.744 158.089 4qYHnP5AmKzXbJhciPV8si https://api.spotify.com/v1/audio-analysis/4qYHnP5AmKzXbJhciPV8si 4 2018-03-29T19:44:07Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ NA FALSE track FALSE TRUE 1 1 233560 https://api.spotify.com/v1/tracks/4qYHnP5AmKzXbJhciPV8si Ain't No Love In The Heart Of The City - Single Version 65 spotify:track:4qYHnP5AmKzXbJhciPV8si FALSE album album https://api.spotify.com/v1/albums/5OkHt7JZ6HSkJH359y2H31 5OkHt7JZ6HSkJH359y2H31 Dreamer 1974-01-01 day spotify:album:5OkHt7JZ6HSkJH359y2H31 10 https://open.spotify.com/album/5OkHt7JZ6HSkJH359y2H31 USMC17449307 https://open.spotify.com/track/4qYHnP5AmKzXbJhciPV8si NA A minor A minor
4 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.441 0.455 10 -14.207 1 0.0485 0.6200 0.001870 0.1350 0.918 160.900 2Mr1bGI2E10K7Mt1UJZ6Mw https://api.spotify.com/v1/audio-analysis/2Mr1bGI2E10K7Mt1UJZ6Mw 4 2018-03-29T19:44:07Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ NA FALSE track FALSE TRUE 1 1 152350 https://api.spotify.com/v1/tracks/2Mr1bGI2E10K7Mt1UJZ6Mw Boom Boom 62 spotify:track:2Mr1bGI2E10K7Mt1UJZ6Mw FALSE album album https://api.spotify.com/v1/albums/3H0HdocoAAEEfiDfcRZauz 3H0HdocoAAEEfiDfcRZauz Burnin' 1962-01-01 day spotify:album:3H0HdocoAAEEfiDfcRZauz 11 https://open.spotify.com/album/3H0HdocoAAEEfiDfcRZauz USVJ10400579 https://open.spotify.com/track/2Mr1bGI2E10K7Mt1UJZ6Mw NA A# major A# major
5 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.598 0.735 2 -10.882 1 0.0973 0.4400 0.000215 0.6810 0.615 111.129 58PSYdY0GFg0LFb2PxYk4T https://api.spotify.com/v1/audio-analysis/58PSYdY0GFg0LFb2PxYk4T 3 2018-03-29T19:44:07Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ https://p.scdn.co/mp3-preview/4cfb55a2ddbd24a5538f21588a85fa5727025aa7?cid=8e358bf3471a4232babc75cfae3ffffc FALSE track FALSE TRUE 1 14 321133 https://api.spotify.com/v1/tracks/58PSYdY0GFg0LFb2PxYk4T Mannish Boy 62 spotify:track:58PSYdY0GFg0LFb2PxYk4T FALSE album album https://api.spotify.com/v1/albums/4fOVcN7X7vQ8L41is621uJ 4fOVcN7X7vQ8L41is621uJ King Of The Electric Blues 1977 year spotify:album:4fOVcN7X7vQ8L41is621uJ 15 https://open.spotify.com/album/4fOVcN7X7vQ8L41is621uJ USSM17600224 https://open.spotify.com/track/58PSYdY0GFg0LFb2PxYk4T NA D major D major
6 37i9dQZF1DXd9rSDyQguIk Blues Classics https://i.scdn.co/image/ab67706f000000027500902fb0aa21f0bdb12cfd Spotify spotify 0.676 0.334 5 -10.572 1 0.0508 0.8620 0.000000 0.3200 0.867 158.000 4KMXlzvtC8xjLseDqDjpeU https://api.spotify.com/v1/audio-analysis/4KMXlzvtC8xjLseDqDjpeU 4 2020-01-15T21:29:46Z FALSE NA https://api.spotify.com/v1/users/ user spotify:user: https://open.spotify.com/user/ NA FALSE track FALSE TRUE 1 22 162040 https://api.spotify.com/v1/tracks/4KMXlzvtC8xjLseDqDjpeU My Babe 59 spotify:track:4KMXlzvtC8xjLseDqDjpeU FALSE album album https://api.spotify.com/v1/albums/2Y2oBBKe7dnNGJrf6HAGBc 2Y2oBBKe7dnNGJrf6HAGBc The Essential Little Walter 1993-06-08 day spotify:album:2Y2oBBKe7dnNGJrf6HAGBc 46 https://open.spotify.com/album/2Y2oBBKe7dnNGJrf6HAGBc USMC15507777 https://open.spotify.com/track/4KMXlzvtC8xjLseDqDjpeU NA F major F major

Należy się ich natychmiast pozbyć, aby zwiększyć przejrzystość zbioru oraz ułatwić dalszą analizę.

Zestaw cech po wstępnej selekcji wyglądał zatem następująco

Opis zestawu cech
Cecha Opis
playlist_name Nazwa playlisty, z której pochodzi utwór
danceability “Taneczność” utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
energy Energiczność utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
key Skala utworu - zmienna kategoryczna, której wartości odpowiadają notacji z https://en.wikipedia.org/wiki/Pitch_class
loudness Głośność utworu w decybelach - zmienna ciągła przyjmująca wartości ujemne
mode Zmienna kategoryczna przyjmująca wartość 1 jeśli utwór jest w skali molowej i 0 jeśli utwór jest w skali durowej
speechiness “Recytowalność” tekstu utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
acousticness Określa szansę na to, że utwór jest akustyczny - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
instrumentalness Określa szansę na to, że utwór nie zawiera wokalu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
liveness Określa szansę na to, że utwór był wykonywany na żywo - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
valence Określa jak bardzo pozytywnie jest nastrojony utwór - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
tempo Zmienna ciągła określająca tempo piosenki w bpm (beats per minute)
time_signature Zmienna kategoryczna określająca metrum muzyczne, gdzie np. wartość 3 oznacza metrum 3/4
track.explicit Zmienna kategoryczna przyjmująca wartość TRUE dla utworów z treściami wulgarnymi i FALSE dla pozostałych
track.duration_ms Zmienna ciągła określająca długość trwania utworu w milisekundach
track_name Identyfikator określający nazwę utworu
track_popularity Popularność utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 100]
track.album.release_date Data wydania utworu
key_name Zmienna kategoryczna określająca skalę, w jakiej jest utwór
mode Zmienna kategoryczna określająca skalę, przyjmująca wartość minor lub major
key_mode Połączenie zmiennej key_name i key_mode
Note

Celowo zostawiłem zmienną track_name będącą identyfikatorem w celu ułatwienia pracy ze zbiorem oraz interpretacji wyników.

Patrząc na powyższy zestaw cech od razu można zauważyć, że zmienna mode_name jest nadmiarowa w stosunku do zmiennej mode, key_name jest nadmiarowa w stosunku do key, a key_mode będąca kombinacją poprzednich zmiennych również jest zbyteczna, od razu więc się ich pozbywam.

Code
df <- df %>%
  select(-c(key_mode, key_name, mode_name))

Utworzenie zmiennej zależnej określającej gatunek utworu

Zmienna określająca gatunek nie była częścią zbioru cech pozyskanych bezpośrednio z API, dlatego muszę ją stworzyć samodzielnie korzystając ze zmiennej określającej nazwę playlisty, z której pochodzi utwór.

Variable Frequency
00s Metal Classics 100
10s Metal Classics 75
All New Rock 50
Blues Classics 70
Blues Standards 58
Classic Blues Guitar 50
Classical Essentials 159
Classical New Releases 68
Country's Greatest Hits 100
Country Top 50 50
Electric Blues Classics 60
Fresh Folk 150
Gold School 50
Hip-Hop Drive 150
Hot Country 50
Jazz Classics 250
Metal Essentials 100
RAP GENERACJA 50
REGGAE 128
Reggae Classics 100
Reggae Party 81
Rock Classics 200
Roots Rising 100
Summer Sunshine Reggae 65
Code
df$genre <- "NA"

metal_playlists <- c("00s Metal Classics", "10s Metal Classics", "Metal Essentials")
hiphop_playlists <- c("Hip-Hop Drive", "Gold School", "RAP GENERACJA")
rock_playlists <- c("All New Rock", "Rock Classics")
blues_playlists <- c("Blues Classics", "Blues Standards", "Classic Blues Guitar", "Electric Blues Classics")
classical_playlists <- c("Classical Essentials", "Classical New Releases")
country_playlists <- c("Country's Greatest Hits", "Country Top 50", "Hot Country")
folk_playlists <- c("Fresh Folk", "Roots Rising")
jazz_playlists <- c("Jazz Classics")
reggae_playlists <- c("Reggae Classics", "Summer Sunshine Reggae", "Reggae Party")

df[df$playlist_name %in% metal_playlists,]$genre <- "metal" 
df[df$playlist_name %in% hiphop_playlists,]$genre <- "hiphop" 
df[df$playlist_name %in% rock_playlists,]$genre <- "rock" 
df[df$playlist_name %in% blues_playlists,]$genre <- "blues" 
df[df$playlist_name %in% classical_playlists,]$genre <- "classical" 
df[df$playlist_name %in% country_playlists,]$genre <- "country" 
df[df$playlist_name %in% folk_playlists,]$genre <- "folk" 
df[df$playlist_name %in% jazz_playlists,]$genre <- "jazz" 
df[df$playlist_name %in% reggae_playlists,]$genre <- "reggae" 

df <- df %>% 
  filter(genre != "NA")

Liczba utworów przynależących do poszczególnych gatunków prezentuje się następująco. Widać, że zgodnie z zamierzeniem każdy gatunek posiada 200-300 obserwacji, dzięki czemu zbiór jest w miarę zbilansowany.

Variable Frequency
blues 238
classical 227
country 200
folk 250
hiphop 250
jazz 250
metal 275
reggae 246
rock 250

Zmienna genre określająca gatunek jest już utworzona, więc mogę się pozbyć cechy playlist_name.

Code
df <- df %>% 
  select(-playlist_name)

Sprawdzenie obecności wartości brakujących

Code
any(is.na(df))
[1] TRUE
danceability energy key loudness mode speechiness acousticness instrumentalness liveness valence tempo time_signature track.explicit track.duration_ms track.name track.popularity track.album.release_date genre
402 NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA NA classical

Wygląda na to, że zbiór posiada wartości brakujące. Po ich wyświetleniu okazuje się jednak, że stanowi je pojedynczy wiersz, w którym wartości każdej zmiennej z wyjątkiem genre to NA. Jest to prawdopodobnie spowodowane tym, że API pobrało z playlisty muzyki klasycznej utwór, który nie jest już dostępny w serwisie. Z racji tego, że jest to jedyny wiersz z wybrakowanymi wartościami w całym zbiorze, śmiało można się go pozbyć bez konieczności imputacji danych.

Code
df <- na.omit(df)
any(is.na(df))
[1] FALSE

Sprawdzenie obecności duplikatów

Na obserwacje z poszczególnych gatunków przeważnie składają się utwory z kilku różnych playlist. Nie jest jednak wykluczone, że w kilku playlistach dla jednego gatunku pewna piosenka się powtórzyła. Należy więc wybadać, czy taka sytuacja wystąpiła i mamy do czynienia z duplikatami.

Is duplicated Frequency
FALSE 2023
TRUE 162

Okazuje się, że w naszym zbiorze powtórzyły się aż 162 utwory. Należy usunąć ich powtórzenia, aby wyeliminować problem identycznych obserwacji.

Code
df <- df %>% 
  distinct(track.name, .keep_all = TRUE)

Po zostawieniu w zbiorze wierszy o unikalnych tytułach problem zostaje rozwiązany.

Is duplicated Frequency
FALSE 2023

Weryfikacja poprawności danych

variable class examples
danceability numeric 0.477, 0.427, 0.518, 0.441, ...
energy numeric 0.433, 0.626, 0.376, 0.455, ...
key integer 11, 8, 9, 10, ...
loudness numeric -6.473, -11.821, -18.248, -14.207, ...
mode integer 0, 1, 0, 1, ...
speechiness numeric 0.0247, 0.0385, 0.0431, 0.0485, ...
acousticness numeric 0.689, 0.0274, 0.933, 0.62, ...
instrumentalness numeric 0.000195, 0.00395, 0.86, 0.00187, ...
liveness numeric 0.151, 0.106, 0.0976, 0.135, ...
valence numeric 0.611, 0.743, 0.744, 0.918, ...
tempo numeric 82.52, 126.877, 158.089, 160.9, ...
time_signature integer 3, 4, 4, 4, ...
track.explicit logical "FALSE", "FALSE", "FALSE", "FALSE", ...
track.duration_ms integer 156653, 219586, 233560, 152350, ...
track.name character "I'd Rather Go Blind", "Pride and Joy", ...
track.popularity integer 77, 67, 65, 62, ...
track.album.release_date character "1968-04-18", "1983", "1974-01-01", "1962-01-01", ...
genre character "blues", "blues", "blues", "blues", ...

Wyświetlenie typu i przykładowych obserwacji dla każdej zmiennej od razu pozwala nam zauważyć parę problemów ze zbiorem, którymi trzeba będzie się zająć. Są to między innymi niepoprawne typy danych lub to, że zmienna przedstawiająca datę, w której opublikowany został utwór, czasami jest wyrażona w dokładności co do nia, a czasami co do roku wydania.

statistic min median mean max sd
danceability 0.0000000 0.5520000 0.5421811 0.9750000 0.1858526
energy 0.0021200 0.5820000 0.5495664 0.9980000 0.2891074
loudness -43.738000 -8.320000 -10.197988 -1.299000 6.514778
speechiness 0.00000000 0.04630000 0.08191068 0.60200000 0.08278790
acousticness 0.00000232 0.25000000 0.38580650 0.99600000 0.36847665
instrumentalness 0.0000000 0.0004180 0.1550245 0.9580000 0.2981258
liveness 0.0243000 0.1220000 0.1740335 0.9790000 0.1368237
valence 0.0000000 0.4910000 0.4870162 0.9700000 0.2618542
tempo 0.00000 115.48600 117.53169 208.57600 31.57082
track.duration_ms 77032.0 230466.0 257778.4 1637893.0 117245.8
track.popularity 0.00000 49.00000 45.40929 93.00000 24.02641

Statystyki opisowe dla zmiennych ciągłych również obrazują kilka anomalii w naszym zbiorze. Widać na przykład, że istnieje utwór z tempem równym 0, co jest niemożliwe. Istnieją również obserwacje o zerowej popularności, co jest mało prawdopodobne biorąc pod uwagę to, że praktycznie każda playlista, z której zostały pobrane dane, zawierała klasyki gatunku.

Usunięcie wiersza z zerowym tempem

Code
df[df$tempo < 40, ] %>% kable("html") %>% kable_styling("striped")
danceability energy key loudness mode speechiness acousticness instrumentalness liveness valence tempo time_signature track.explicit track.duration_ms track.name track.popularity track.album.release_date genre
268 0 0.0842 9 -19.397 1 0 0.755 0.889 0.262 0 0 0 FALSE 432533 Serenade for Strings in E Major, Op. 22, B. 52: II. Tempo di valse 65 1991-01-01 classical

Po wyświetleniu wszystkich wierszy, które mają tempo poniżej 40 bpm okazuje się, że jest tylko jeden taki utwór. Można go więc usunąć bez obawy, że spowoduje to wielką różnicę w zbiorze.

Code
df <- df %>% 
  filter(tempo != 0)

Rezygnacja ze zmiennej popularity

Code
ggplot(df, aes(x = track.popularity)) + geom_histogram(bins = 30, fill = "#F8766D") + labs(title = "Histogram zmiennej `popularity`")

Wyświetlenie histogramu zmiennej popularity pozwala zauważyć, że mamy bardzo dużo zmiennych o popularności równej lub bliskiej 0. Sprawdźmy, ile wierszy ma “zerową” popularność.

Code
nrow(df[df$track.popularity == 0, ])
[1] 245
Code
tail(df[df$track.popularity == 0, ]$track.name, 10)
 [1] "Repentless"                                    
 [2] "Storytime"                                     
 [3] "Faster"                                        
 [4] "Unbreakable"                                   
 [5] "To Hell And Back"                              
 [6] "Reign Of Darkness"                             
 [7] "The Dirt (Est. 1981) [feat. Machine Gun Kelly]"
 [8] "Koolaid"                                       
 [9] "Looking Down The Barrel Of Today"              
[10] "My Own Grave"                                  

Mamy aż 245 takich wierszy. Można więc wywnioskować, że zmienna ta nie jest definiowana przez serwis Spotify w prawidłowy sposób i zawiera nieprawdopodobne wartości, co potwierdza fakt, że utwór Slayera “Repentless” również ma “zerową” popularność. Sprawiło to, że skłoniłem się do wyeliminowania jej ze zbioru danych.

Code
df <- df %>% 
  select(-track.popularity)

Zmiana wartości w zmiennej time_signature

Value Frequency
1 18
3 209
4 1771
5 24

Po wyświetleniu liczby utworów w poszczególnym metrum okazuje się, że zbiór jest zdominowany przez utwory w najpopularniejszym metrum, jakim jest 4/4. Podejrzane jest jednak to, że mamy 18 utworów w metrum 1/4, co najprawdopodobniej jest błędem. Przyjrzyjmy się utworom w tym metrum, żeby zidentyfikować źródło takiego kodowania.

Code
tail(df[df$time_signature == 1, ]$track.name)
[1] "Die tote Stadt, Op. 12: Mein Sehnen, mein Wähnen (Pierrot's Tanzlied)"
[2] "Hard To Tell"                                                         
[3] "Wanna Get To Know You"                                                
[4] "Solsbury Hill"                                                        
[5] "Money - 2011 Remastered Version"                                      
[6] "Pneuma"                                                               

Okazuje się, że metrum to jest przypisane albo utworom, które posiadają “płynną” strukturę, charakterystyczną dla muzyki klasycznej, albo tym, które cechuje niestabilne metrum, czyli takie, które często zmienia się w trakcie trwania utworu. Dobrym przykładem jest utwór “Money” zespołu Pink Floyd, (motyw główny jest w 7/4, ale potem podczas “mostku” przechodzi w 4/4, a następnie 6/4, żeby potem znowu wrócić do 7/4), lub utwór “Pneuma” zespołu Tool, gdzie metrum zmienia się praktycznie cały czas (posiada on nawet powtarzający się segment, w którym mamy do czynienia z następującymi po sobie taktami w metrum 33/16, 33/16, 28/16 i 30/16!).

Nic więc dziwnego, że API przy wyodrębnianiu metrum z tych piosenek “zwariowało” i sklasyfikowało je jako 1/4, co oznaczałoby mniej więcej, że na cały takt przypada jedno uderzenie, co oczywiście jest w tym przypadku nieprawdą. Z racji tego, że utwory w metrum 3/4 i 5/4, zarówno jak “utwory w metrum 1/4” występują stosunkowo rzadko, postanowiłem złączyć je w jedną kategorię “other”, natomiast utwory w metrum 4/4 pozostawić niezmienione.

Code
df$time_signature <- as.character(df$time_signature)
df$time_signature <- ifelse(df$time_signature == "4",
                            df$time_signature, 
                            "other")

Tak prezentuje się teraz podział utworów ze względu na obecność metrum 4/4 lub innego.

Value Frequency
4 1771
other 251

Zmiana zmiennej track.album.release_date na zmienną decade

Z poprzednich wnioskowań wyniknęło, że zmienna przedstawiająca datę wydania utworu jest wyrażana z różną dokładnością, ponieważ czasem mamy w niej podaną całą datę, a czasami tylko rok. W celu ujednolicenia dokładności wartości tej cechy zdecydowałem się z każdej daty wyciągnąć tylko rok. Następnie uznałem, że dobrym pomysłem byłoby przypisanie każdego roku do poszczególnej dekady, w celu skategoryzowania zmiennej. Piosenek z lat 30’, 40’, 50’ i 60’ było mało, więc zgrupowałem je do jednej kategorii “other”, żeby grupy były bardziej zbilansowane.

Code
df$track.album.release_date <- substr(df$track.album.release_date, 1, 4)
colnames(df)[16] <- "release_year"
df$release_year <- as.integer(df$release_year)
df$decade <- (df$release_year - df$release_year %% 10) %% 100
df[df$decade %in% c(30, 40, 50, 60), ]$decade <- "<70"
df[df$decade == 0, ]$decade <- "00"
df$decade <- as.character(df$decade)
df$decade <- factor(df$decade, levels = c("<70", "70", "80", "90", "00", "10", "20"))

df <- df %>% 
  select(-release_year)

W ten sposób utworzyłem kategoryczną zmienną decade, która przyjmuję następujące wartości z daną częstotliwością:

Value Frequency
<70 233
70 140
80 126
90 227
00 447
10 265
20 584

Zmiana wartości w kolumnie track.explicit na 0 i 1

Zmienna track.explicit przyjmuje wartości logiczne TRUE lub FALSE, dlatego decyduję się je zamienić na wartości 1 i 0, gdzie 1 odpowiada utworowi, który zawiera treści wulgarne.

Code
df$track.explicit <- ifelse(df$track.explicit == TRUE, 1, 0)

Rozkład tej cechy przedstawia się następująco:

Value Frequency
0 1714
1 308

Kodowanie zmiennych kategorycznych

Zmienne, które przyjmują ograniczoną liczbę wartości, odpowiednio zakodowałem jako zmienne kategoryczne.

Code
df$genre <- factor(df$genre)
df$key <- factor(df$key)
df$mode <- factor(df$mode)
df$time_signature <- factor(df$time_signature)
df$track.explicit <- factor(df$track.explicit)

Zmiana jednostki w zmiennej track.duration_ms na sekundy

Zmienna track.duration_ms jest wyrażana w milisekundach, przez co przyjmuje bardzo duże wartości. W celu ułatwienia interpretacji tej cechy zamienię jednostkę na sekundy dzieląc jej wartości przez 1000.

Code
df$track.duration_s <- df$track.duration_ms / 1000
df <- df %>% 
  select(-track.duration_ms)

Efekt przetwarzania i czyszczenia zbioru

variable class examples
danceability numeric 0.477, 0.427, 0.518, 0.441, ...
energy numeric 0.433, 0.626, 0.376, 0.455, ...
key Factor w/ 12 levels "11", "8", "9", "10", ...
loudness numeric -6.473, -11.821, -18.248, -14.207, ...
mode Factor w/ 2 levels "0", "1", "0", "1", ...
speechiness numeric 0.0247, 0.0385, 0.0431, 0.0485, ...
acousticness numeric 0.689, 0.0274, 0.933, 0.62, ...
instrumentalness numeric 0.000195, 0.00395, 0.86, 0.00187, ...
liveness numeric 0.151, 0.106, 0.0976, 0.135, ...
valence numeric 0.611, 0.743, 0.744, 0.918, ...
tempo numeric 82.52, 126.877, 158.089, 160.9, ...
time_signature Factor w/ 2 levels "other", "4", "4", "4", ...
track.explicit Factor w/ 2 levels "0", "0", "0", "0", ...
track.name character "I'd Rather Go Blind", "Pride and Joy", ...
genre Factor w/ 9 levels "blues", "blues", "blues", "blues", ...
decade Factor w/ 7 levels "<70", "80", "70", "<70", ...
track.duration_s numeric 156.653, 219.586, 233.56, 152.35, ...

Widać, że wszystkie zmienne mają teraz poprawnie zakodowane typy.

statistic min median mean max sd
danceability 0.0623000 0.5520000 0.5424492 0.9750000 0.1855068
energy 0.0021200 0.5820000 0.5497965 0.9980000 0.2889935
loudness -43.738000 -8.318500 -10.193438 -1.299000 6.513174
speechiness 0.02280000 0.04635000 0.08195119 0.60200000 0.08278832
acousticness 0.00000232 0.24900000 0.38562391 0.99600000 0.36847625
instrumentalness 0.0000000 0.0004145 0.1546615 0.9580000 0.2977521
liveness 0.0243000 0.1215000 0.1739900 0.9790000 0.1368435
valence 0.0280000 0.4910000 0.4872571 0.9700000 0.2616948
tempo 48.71800 115.48850 117.58982 208.57600 31.47017
track.duration_s 77.0320 230.4130 257.6919 1637.8930 117.2103

Statystyki opisowe również nie wykazują żadnych nieprawidłowości w zbiorze.

Wizualizacja zależności pomiędzy zmiennymi

Rozkłady zmiennych

Important

Wyraźnie widać, że część zmiennych ciągłych ma rozkłady asymetryczne gruboogonowe. Oznacza to, że niektóre modele uczenia maszynowego będą wymagały uprzedniej transformacji tych zmiennych w celu wymuszenia większej symetryczności rozkładów.

Rozkłady dekad w zależności od gatunku

Można tu zauważyć kilka ciekawych faktów. Utwory bluesowe i jazzowe w zdecydowanej większości powstały dawniej niż w latach 70’, z kolei najwięcej utworów klasycznych w zbiorze pochodzi z lat 20’. Innym ciekawym spostrzeżeniem jest to, że prawie wszystkie piosenki folkowe pochodzą z lat 20’ -jedynie garstka z nich pochodzi jeszcze z lat 10’. Warto zaznaczyć, że rozkłady te nie odzwierciedlają jednak faktycznego procesu popularyzacji tych gatunków - odzwierciedlają one jedynie charakterystykę zbioru.

Treści wulgarne w zależności od gatunku

Nie jest zaskoczeniem fakt, że najwięcej utworów zawierających treści wulgarne pochodzi z gatunku hip-hop (zawiera je aż prawie 90% utworów z tego gatunku). Kolejny w kolejność jest metal, ponad 1/5 gatunków z tego utworu zawiera treści nieodpowiednie dla młodych odbiorców. Jedynymi gatunkami, w których nie ma żadnych wulgaryzmów są blues, muzyka klasyczna i jazz.

Energiczność vs. głośność utworów

Energiczność i głośność utworu to cechy, które z pozoru wydają się być powiązane. Powyższy wykres dowodzi, że rzeczywiście występuje taka zależność: im utwór jest bardziej energiczny, tym głośniejszy. Dodatkowo można zauważyć, że niektóre gatunki układają się w “chmury” - utwory metalowe są na przykład ściśle skupione w prawym górnym rogu wykresu, co oznacza że są jednocześnie najgłośniejsze i najbardziej energiczne.

Taneczność vs. pozytywność utworów

Czy to, że utwór jest bardziej pozytywny oznacza, że nadaje się również bardziej do tańczenia? Powyższy wykres pokazuje, że jest w tym trochę prawdy.Można zauważywć, że najbardziej tanecznym i pozytywnym gatunkiem jest reggae. Może natomiast dziwić fakt, iż wygląda na to, że wiele utworów klasycznych ze zbioru nie nadaje się do tańca i jest wyjątkowo depresyjna.

Głośność utworów na przestrzeni lat

Ciekawym faktem jest to, że w więkoszści gatunków utwory stawały się głośniejsze z dekady na dekadę. Nie jest zaskoczeniem, że metal prawie zawsze był najgłośniejszym gatunkiem, w tym latach 20’ charakteryzował się średnią głośnością na poziomie -3.23 dB, co znacznie kontrastuje z -23.68 dB w przypadku muzyki klasycznej z tej samej dekady.

Rozkłady czasu trwania utworów

Patrząc na wykresy pudełkowe długości piosenek w zależności od gatunku można podejrzewać, że jest szansa na to, że niektóre rozkłady są do siebie zbliżone. Żeby sprawdzić to sprawdzić zamierzam przeprowadzić test ANOVA. Najpierw sprawdzam jednak wymagane założenia o wielowymiarowej normalności w grupach, zbalansowaniu grup i ich homogeniczności.

genre sample_size p_value
blues 181 0.0000000
classical 224 0.0000000
country 187 0.0020730
folk 226 0.0095814
hiphop 238 0.0000016
jazz 236 0.0000000
metal 248 0.0000000
reggae 235 0.0000597
rock 247 0.0000000
Code
bartlett.test(df$track.duration_s, df$genre)

    Bartlett test of homogeneity of variances

data:  df$track.duration_s and df$genre
Bartlett's K-squared = 1323, df = 8, p-value < 2.2e-16

Wygląda na to, że żadne z założeń nie jest spełnione, zatem przeprowadzę test Kruskala-Wallisa będący parametrycznym odpowiednikiem testu ANOVA.

Code
kruskal.test(track.duration_s ~ genre, df)

    Kruskal-Wallis rank sum test

data:  track.duration_s by genre
Kruskal-Wallis chi-squared = 278.84, df = 8, p-value < 2.2e-16

Test ten wykazał, że należy odrzucić hipotezę mówiącą o tym, że średnie długości piosenek w gatunkach są na takim samym poziomie. Żeby sprawdzić, które z nich się różnią pomiędzy sobą, przeprowadzę test Dunna.

Code
dunntest <- dunn_test(df, track.duration_s ~ genre, p.adjust.method = "bonferroni")

Na wykresie pokazane są tylko wartości p-value, które były istotne statystycznie. Okazuje się więc, że przypuszczenia były niesłuszne - w każdym przypadku należy odrzucić hipotezę, że średnia długość utworów jest taka sama.

Zależność zmiennej valence od skali

W praktyce, skale molowe i oparte na nich utwory muzyczne uważa się za mające smutne brzmienie, w odróżnieniu od skal durowych, które uważa się za radosne.

Patrząc na wykresy pudełkowe wygląda na to, że zmienna valence nie zależy od tego, czy utwór jest w skali durowej czy molowej. Potwierdzenie otrzymamy przeprowadzając test t-Studenta wcześniej sprawdzając założenie o jednorodności wariancji.

Code
var.test(df[df$mode == 0, ]$valence, df[df$mode == 1, ]$valence)

    F test to compare two variances

data:  df[df$mode == 0, ]$valence and df[df$mode == 1, ]$valence
F = 1.0126, num df = 661, denom df = 1359, p-value = 0.8461
alternative hypothesis: true ratio of variances is not equal to 1
95 percent confidence interval:
 0.8891214 1.1566664
sample estimates:
ratio of variances 
          1.012574 

Założenie o jednorodności wariancji jest spełnione. Przeprowadzę zatem t-test.

Code
t.test(valence ~ mode, data = df)

    Welch Two Sample t-test

data:  valence by mode
t = -1.4898, df = 1302.8, p-value = 0.1365
alternative hypothesis: true difference in means between group 0 and group 1 is not equal to 0
95 percent confidence interval:
 -0.042885818  0.005864111
sample estimates:
mean in group 0 mean in group 1 
      0.4748066       0.4933175 

P-wartość przeprowadzonego testu wskazuje na to, że nie ma podstaw do odrzucenia hipotezy o równości średnich w tych rozkładach. Oznacza to, że w naszym zbiorze cecha mówiąca o nastroju utworu nie zależy od skali, co jest dosyć ciekawym wnioskiem.

Korelacja pomiędzy zmiennymi niezależnymi ciągłymi

Wysoka korelacja między zmiennymi zależnymi może stanowić problem podczas budowy modelu uczenia maszynowego. Wiele źródeł podaje, że można ją uznać za wysoką, jeżeli przekracza wartość 0.7. W naszym zbiorze wartość ta jest przekraczana trzy razy, pomiędzy parami zmiennych:

  • energy-loudness,

  • energy-acousticness,

  • loudness-acousticness.

Na wykresach pomiędzy tymi zmiennymi widoczne są odkryte zależności, szczególnie w przypadku energiczności i głośności, gdzie obserwacje dosyć wyraźnie układają się wzdłuż krzywej dopasowanej wielomianem drugiego stopnia.

Na wykresie trójwymiarowym widać jak bardzo te trzy cechy zależą od siebie nawzajem.

Żeby sprawdzić, czy możliwe będzie zastąpienie tych trzech zmiennych tylko jedną z nich, przeprowadzę PCA w celu sprowadzenia trzech wymiarów do jednego, a następnie sprawdzę, która z tych zmiennych ma największy wpływ na kształtowanie tego wymiaru.

Z powyższego wykresu widać, że pierwszy wymiar wyjaśnia aż około 80% zmienności.

Wykres wkładu zmiennych w pierwszy wymiar oraz linia odcięcia sugerują, że zmienna energy ma istotnie największy wpływ w tworzenie się tego wymiaru. Na tej podstawie decyduję się więc na to, żeby z trójki zmiennych energy, acousticness i loudness zostawić tylko energy, co wyeliminuje problem wysokiej korelacji pomiędzy częścią zmiennych zależnych w zbiorze. Później sprawdzę też jednak jak będzie się spisywał model zbudowany na zbiorze z usuniętymi zmiennymi. “Wysoka korelacja” jest bowiem płynnym określeniem - niektóre źródła podają, że zaczyna się ona już od wartości 0.7, natomiast czasami o wysokiej korelacji mówi się dopiero od 0.9. Jest zatem szansa, że korelacja, która tu wystąpiła, nie miała wpływu na przyszłą skuteczność predykcyjną modelu.

Code
df2 <- df

df <- df %>% 
  select(-c(loudness, acousticness))

Przygotowanie modelu klasyfikacji wieloklasowej

Zbiór nie zawiera już braków danych ani duplikatów, wyselekcjonowane zmienne są poprawnie zakodowane i nie zawierają nieprawidłowości, a problem wysokiej korelacji został wyeliminowany, dlatego można przejść do przygotowywania modelu przewidującego gatunek muzyczny.

Podział zbioru na treningowy i testowy

Zbiór dzielę na treningowy i testowy w proporcjach 0.8 ustawiając parametr strata o wartości “genre”, aby proporcje liczebności grup zmiennej objaśnianej były zachowane.

Code
set.seed(170)

split <- initial_split(df, prop = 0.8, strata = genre)
spotify_train <- training(split)
spotify_test <- testing(split)
split
<Training/Testing/Total>
<1614/408/2022>

Przygotowanie 10-krotnej walidacji krzyżowej

Code
spotify_folds <- vfold_cv(spotify_train, strata = genre)
spotify_folds
#  10-fold cross-validation using stratification 
# A tibble: 10 × 2
   splits             id    
   <list>             <chr> 
 1 <split [1448/166]> Fold01
 2 <split [1450/164]> Fold02
 3 <split [1452/162]> Fold03
 4 <split [1453/161]> Fold04
 5 <split [1453/161]> Fold05
 6 <split [1453/161]> Fold06
 7 <split [1454/160]> Fold07
 8 <split [1454/160]> Fold08
 9 <split [1454/160]> Fold09
10 <split [1455/159]> Fold10

Przygotowanie odpowiednich receptur

W poniższej tabeli przedstawione są modele, których zamierzam użyć do budowy modeli predykcyjnych na podstawie mojego zbioru, a także rodzaj preprocessingu, który jest dla nich rekomendowany.

Model Dummy vars Zero-variance Decorrelate Normalize Transform
Random forest
Bagging
XGBoost
Mlp
Knn
Svm
Naive Bayes

Rodzaje preprocessingu:

  • dummy vars - przekształcenie zmiennych kategorycznych w tzw. dummy variables

  • zero-variance - usunięcie zmiennych, które zawierają tylko pojedynczą wartość

  • decorrelate - wyeliminowanie problemu wysokiej korelacji pomiędzy zmiennymi objaśniającymi

  • normalize - normalizacja

  • transform - wymuszenie większej symetryczności rozkładu asymetrczynych zmiennych

✓ oznacza, że dany rodzaj preprocessingu jest rekomendowany, ✗ - przeciwnie, natomiast ◌ oznacza, że dany zabieg może, ale nie musi pomóc w osiągnięciu lepszych wyników.

Zbiór po uprzednich zabiegach nie posiada już problemu wysokiej korelacji, więc nie wymaga dalszej “dekorelacji”. Nie zawiera on także zmiennych zawierających pojedynczą wartość, jednak zawrę ten element preprocessingu w recepturach na wszelki wypadek.

Na podstawie tabelki tworzę trzy receptury:

  • basic_recipe określa predyktory i zmienną objaśnianą w modelu, nadaje zmiennej track_name inną rolę, żeby nie mogła być używana przy trenowaniu modelu, a także usuwane są potencjalne zmienne przyjmujące pojedynczą wartość,

  • dummy_recipe dodatkowo zamienia zmienne kategoryczne na dummy variables,

  • transform_recipe dodatkowo transformuje zmienne numeryczne za pomocą transformacji Yeo-Johnsona i je normalizuje.

Code
basic_recipe <-
  recipe(genre~., data = spotify_train) %>% 
  update_role(track.name, new_role = "track_name") %>% 
  step_zv(all_predictors())
# A tibble: 15 × 4
   variable         type      role       source  
   <chr>            <list>    <chr>      <chr>   
 1 danceability     <chr [2]> predictor  original
 2 energy           <chr [2]> predictor  original
 3 key              <chr [3]> predictor  original
 4 mode             <chr [3]> predictor  original
 5 speechiness      <chr [2]> predictor  original
 6 instrumentalness <chr [2]> predictor  original
 7 liveness         <chr [2]> predictor  original
 8 valence          <chr [2]> predictor  original
 9 tempo            <chr [2]> predictor  original
10 time_signature   <chr [3]> predictor  original
11 track.explicit   <chr [3]> predictor  original
12 track.name       <chr [3]> track_name original
13 decade           <chr [3]> predictor  original
14 track.duration_s <chr [2]> predictor  original
15 genre            <chr [3]> outcome    original
Code
dummy_recipe <- 
  basic_recipe %>% 
  step_dummy(all_nominal_predictors())
Code
transform_recipe <-
  dummy_recipe %>% 
  step_YeoJohnson(all_numeric_predictors()) %>% 
  step_normalize(all_numeric_predictors())
variable type role source
danceability double , numeric predictor original
energy double , numeric predictor original
speechiness double , numeric predictor original
instrumentalness double , numeric predictor original
liveness double , numeric predictor original
valence double , numeric predictor original
tempo double , numeric predictor original
track.name factor , unordered, nominal track_name original
track.duration_s double , numeric predictor original
genre factor , unordered, nominal outcome original
key_X1 double , numeric predictor derived
key_X2 double , numeric predictor derived
key_X3 double , numeric predictor derived
key_X4 double , numeric predictor derived
key_X5 double , numeric predictor derived
key_X6 double , numeric predictor derived
key_X7 double , numeric predictor derived
key_X8 double , numeric predictor derived
key_X9 double , numeric predictor derived
key_X10 double , numeric predictor derived
key_X11 double , numeric predictor derived
mode_X1 double , numeric predictor derived
time_signature_other double , numeric predictor derived
track.explicit_X1 double , numeric predictor derived
decade_X70 double , numeric predictor derived
decade_X80 double , numeric predictor derived
decade_X90 double , numeric predictor derived
decade_X00 double , numeric predictor derived
decade_X10 double , numeric predictor derived
decade_X20 double , numeric predictor derived

Zdefiniowanie modeli i przygotowanie ich parametrów do tuningu

Code
rf_spec <- rand_forest(mtry = tune(), min_n = tune(), trees = tune()) %>% 
  set_engine("ranger") %>% 
  set_mode("classification")

bagging_spec <- bag_tree(min_n = tune(), tree_depth = tune()) %>% 
  set_engine("rpart", times = 60L) %>% 
  set_mode("classification")

xgb_spec <- boost_tree(mtry = tune(), trees = tune(), min_n = tune(), tree_depth = tune(), learn_rate = tune(), loss_reduction = tune(), sample_size = tune()) %>% 
  set_engine("xgboost") %>% 
  set_mode("classification")
  
mlp_spec <- 
  mlp(hidden_units = tune(), penalty = tune(), epochs = tune()) %>% 
  set_engine("nnet", trace = 0) %>% 
  set_mode("classification")

nearest_spec <- nearest_neighbor(neighbors = tune(), weight_func = tune(), dist_power = tune()) %>% 
  set_engine("kknn") %>% 
  set_mode("classification")

svm_spec <- svm_rbf(cost = tune(), rbf_sigma = tune()) %>%
  set_engine("kernlab") %>% 
  set_mode("classification")

bayes_spec <- naive_Bayes(smoothness = tune(), Laplace = tune()) %>% 
  set_engine("klaR") %>% 
  set_mode("classification")

Przypisanie modeli do odpowiednich przepływów pracy

Code
basic <- workflow_set(preproc = list(basic = basic_recipe), 
                      models = list(random_forest = rf_spec, 
                                    bagging = bagging_spec, 
                                    naive_bayes = bayes_spec))
Code
dummy <- workflow_set(preproc = list(dummy = dummy_recipe), 
                      models = list(xgb = xgb_spec))
Code
transform <- workflow_set(preproc = list(transform = transform_recipe), 
                          models = list(mlp = mlp_spec, 
                                        knn = nearest_spec, 
                                        svm = svm_spec))
Code
all_workflows <- bind_rows(basic, dummy, transform) %>% 
  mutate(wflow_id = gsub("(basic_)|(dummy_)|(transform_)", "", wflow_id))
# A workflow set/tibble: 7 × 4
  wflow_id      info             option    result    
  <chr>         <list>           <list>    <list>    
1 random_forest <tibble [1 × 4]> <opts[0]> <list [0]>
2 bagging       <tibble [1 × 4]> <opts[0]> <list [0]>
3 naive_bayes   <tibble [1 × 4]> <opts[0]> <list [0]>
4 xgb           <tibble [1 × 4]> <opts[0]> <list [0]>
5 mlp           <tibble [1 × 4]> <opts[0]> <list [0]>
6 knn           <tibble [1 × 4]> <opts[0]> <list [0]>
7 svm           <tibble [1 × 4]> <opts[0]> <list [0]>

Tuning hiperparametrów modeli

Przy poszukiwaniu najlepszego modelu o najlepszych parametrach będę posługiwał się następującymi metrykami: accuracy, bal_accuracy, precision, recall, sensitivity oraz specificity.

Code
grid_ctrl <- 
  control_grid(
    save_pred = TRUE,
    parallel_over = "everything",
    save_workflow = TRUE)
Code
cl <- makePSOCKcluster(4)
registerDoParallel(cl)

grid_results <- 
 all_workflows %>% 
 workflow_map(seed = 170, 
              resamples = spotify_folds, 
              grid = 25, 
              metrics = metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity), 
              control = grid_ctrl)

stopCluster(cl)
registerDoSEQ()

Za pomocą tych samych kroków znajdę również najlepsze parametry dla zbioru, w którym nie usunąłem zmiennych loudness i acousticness. Podział danych na zbiór treningowy i testowy będzie taki sam, dzięki czemu będzie można zobaczyć gdzie modele się różniły.

Code
set.seed(170)

split2 <- initial_split(df2, prop = 0.8, strata = genre)
spotify_train2 <- training(split2)
spotify_test2 <- testing(split2)

spotify_folds2 <- vfold_cv(spotify_train2, strata = genre)

basic_recipe2 <-
  recipe(genre~., data = spotify_train2) %>% 
  update_role(track.name, new_role = "track_name") %>% 
  step_zv(all_predictors())

dummy_recipe2 <- 
  basic_recipe2 %>% 
  step_dummy(all_nominal_predictors())

transform_recipe2 <-
  dummy_recipe2 %>% 
  step_YeoJohnson(all_numeric_predictors()) %>% 
  step_normalize(all_numeric_predictors())

basic2 <- workflow_set(preproc = list(basic = basic_recipe2), 
                      models = list(random_forest = rf_spec, 
                                    bagging = bagging_spec, 
                                    naive_bayes = bayes_spec))

dummy2 <- workflow_set(preproc = list(dummy = dummy_recipe2), 
                      models = list(xgb = xgb_spec))

transform2 <- workflow_set(preproc = list(transform = transform_recipe2), 
                          models = list(mlp = mlp_spec, 
                                        knn = nearest_spec, 
                                        svm = svm_spec))

all_workflows2 <- bind_rows(basic2, dummy2, transform2) %>% 
  mutate(wflow_id = gsub("(basic_)|(dummy_)|(transform_)", "", wflow_id))
Code
grid_ctrl2 <- 
  control_grid(
    save_pred = TRUE,
    parallel_over = "everything",
    save_workflow = TRUE)

cl <- makePSOCKcluster(4)
registerDoParallel(cl)

grid_results2 <- 
 all_workflows2 %>% 
 workflow_map(seed = 170, 
              resamples = spotify_folds2, 
              grid = 25, 
              metrics = metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity), 
              control = grid_ctrl2)

stopCluster(cl)
registerDoSEQ()

Wyniki tuningu

Zbiór bez loudness i acousticness

# A tibble: 6 × 3
  wflow_id      .metric       mean
  <chr>         <chr>        <dbl>
1 random_forest accuracy     0.763
2 random_forest bal_accuracy 0.862
3 random_forest precision    0.759
4 random_forest recall       0.753
5 random_forest sensitivity  0.753
6 random_forest specificity  0.970

Zbiór z tymi zmiennymi

# A tibble: 6 × 3
  wflow_id      .metric       mean
  <chr>         <chr>        <dbl>
1 random_forest accuracy     0.783
2 random_forest bal_accuracy 0.873
3 random_forest precision    0.783
4 random_forest recall       0.772
5 random_forest sensitivity  0.772
6 random_forest specificity  0.973

Wygląda na to, że według każdej metryki model lasu losowego okazuje się być najlepszy. Dodatkowo wygląda na to, że model zbudowany na podstawie zbioru ze zmiennymi loudness i acousticness jest minimalnie lepszy.

Zbiór bez loudness i acousticness

# A tibble: 6 × 2
  wflow_id      bal_accuracy
  <chr>                <dbl>
1 random_forest        0.862
2 random_forest        0.860
3 random_forest        0.861
4 random_forest        0.860
5 random_forest        0.860
6 random_forest        0.860

Zbiór z tymi zmiennymi

# A tibble: 6 × 2
  wflow_id      bal_accuracy
  <chr>                <dbl>
1 random_forest        0.873
2 random_forest        0.872
3 random_forest        0.872
4 random_forest        0.871
5 random_forest        0.871
6 random_forest        0.870

Po przyjęciu za główną metrykę porównawczą balanced accuracy model lasu losowego również zdecydowanie wygląda na najlepszy. Można więc na tym etapie przypuszczać, że wyeliminowanie korelacji nie wpłynęło na poprawę modelu, a nawet nieznacznie ją pogorszyło, co jednak zweryfikuję później ostatecznie sprawdzając dokładność każdego z modeli na zbiorze testowym.

# A tibble: 7 × 2
  model            bal_accuracy
  <chr>                   <dbl>
1 rand_forest             0.862
2 boost_tree              0.858
3 bag_tree                0.851
4 mlp                     0.823
5 svm_rbf                 0.816
6 nearest_neighbor        0.786
7 naive_Bayes             0.709
# A tibble: 7 × 2
  model            bal_accuracy
  <chr>                   <dbl>
1 rand_forest             0.873
2 boost_tree              0.868
3 bag_tree                0.863
4 svm_rbf                 0.835
5 mlp                     0.831
6 nearest_neighbor        0.814
7 naive_Bayes             0.744

Zestawienie najlepszego modelu każdego rodzaju potwierdza, że w naszym zadaniu najlepiej poradziły sobie modele “drzewiaste”, najsłabiej natomiast spisał się naiwny klasyfikator bayesowski. Dodatkowo wyraźnie widać, że zostawienie usuniętych wcześniej zmiennych poprawiło średnią dokładność każdego z modeli. Kolejność modeli w zależności od użytego zbioru od najdokładniejszego do najmniej dokładnego również jest podobna - jedynie model svm okazał się minimalnie lepszy i wyprzedził model mlp w przypadku modeli zbudowanych za pomocą zbioru z większą ilością zmiennych.

Stworzenie najlepszych modeli o parametrach wybranych przez przeszukiwanie siatki

Ostatecznie decyduję się na stworzenie dwóch modeli lasu losowego dla każdego zbioru, ponieważ w każdym zestawieniu radził on sobie najlepiej. Jako model1 będę od teraz oznaczał model bez dodatkowych zmiennych, drugi natomiast będzie oznaczany jako model2.

Code
best_results <- 
  grid_results %>% 
  extract_workflow_set_result("random_forest") %>% 
  select_best(metric = "bal_accuracy")
  
best_results2 <- 
  grid_results2 %>% 
  extract_workflow_set_result("random_forest") %>% 
  select_best(metric = "bal_accuracy")

Model1

# A tibble: 1 × 3
   mtry trees min_n
  <int> <int> <int>
1     3  1871     5

Model2

# A tibble: 1 × 3
   mtry trees min_n
  <int> <int> <int>
1     6  1927     5

Model lasu losowego wybrane metodą przeszukiwania siatki mają kolejno parametry:

  • mtry = 3,

  • trees = 1871,

  • min_n = 5

dla Model1 oraz

  • mtry = 6,

  • trees = 1927,

  • min_n = 5

dla Model2.

Code
best_model_random_forest <- rand_forest(trees = best_results$trees,
                             mtry = best_results$mtry,
                             min_n = best_results$min_n) %>%
  set_engine("ranger", importance = "impurity") %>% 
  set_mode("classification")
  
best_model_random_forest2 <- rand_forest(trees = best_results2$trees,
                             mtry = best_results2$mtry,
                             min_n = best_results2$min_n) %>%
  set_engine("ranger", importance = "impurity") %>% 
  set_mode("classification")

Sprawdzenie dokładności modelu na zbiorze testowym

Ostatecznie o tym, który model spisuje się dokładniej i jaka jest faktyczna zdolność predykcyjna zbudowanych modeli można się przekonać sprawdzając ich zdolności na zbiorze testowym.

Code
rf_wflow <- 
  workflow() %>% 
  add_model(best_model_random_forest) %>% 
  add_recipe(basic_recipe)

rf_fit <- 
  rf_wflow %>% 
  fit(data = spotify_train)
  
rf_wflow2 <- 
  workflow() %>% 
  add_model(best_model_random_forest2) %>% 
  add_recipe(basic_recipe2)

rf_fit2 <- 
  rf_wflow2 %>% 
  fit(data = spotify_train2)
Code
test_pred <- predict(rf_fit, new_data = spotify_test) %>%
  bind_cols(spotify_test) 

metrics_set <- metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity)

metrics_test <- metrics_set(test_pred,
                    truth = genre, estimate = .pred_class)
  
test_pred2 <- predict(rf_fit2, new_data = spotify_test2) %>%
  bind_cols(spotify_test2) 

metrics_test2 <- metrics_set(test_pred2,
                    truth = genre, estimate = .pred_class)

Model1

# A tibble: 6 × 3
  .metric      .estimator .estimate
  <chr>        <chr>          <dbl>
1 accuracy     multiclass     0.740
2 bal_accuracy macro          0.849
3 precision    macro          0.735
4 recall       macro          0.731
5 sensitivity  macro          0.731
6 specificity  macro          0.968

Model2

# A tibble: 6 × 3
  .metric      .estimator .estimate
  <chr>        <chr>          <dbl>
1 accuracy     multiclass     0.779
2 bal_accuracy macro          0.871
3 precision    macro          0.770
4 recall       macro          0.770
5 sensitivity  macro          0.770
6 specificity  macro          0.972

Przy porównaniu metryk na zbiorach testowych ostatecznie widać, że Model2 ma lepsze zdolności generalizacyjne. Wartość metryki bal_accuracy wynosząca 0.87 jest zadowalająca i oznacza, że model dobrze radzi sobie na danych, których wcześniej nie widział. Różnica między metrykami obydwu modeli nie jest bardzo duża, jednak z pewnością będzie zauważalna na macierzy klasyfikacji.

Macierze klasyfikacji

Tutaj rzeczywiście rzuca się w oczy poprawa modelu o większej ilości zmiennych. Praktycznie każdy gatunek (z wyjątkiem folku i bluesa) ma teraz więcej poprawnie sklasyfikowanych obserwacji.

Z powyższych wykresów można wysnuć wiele ciekawych wniosków:

  • każdy model najczęściej, bo aż 10 razy, pomylił blues z jazzem, co jest dosyć zrozumiałym błędem zważając na podobieństwo między tymi gatunkami

  • w Model2 country zostało pomylone z największą ilością gatunku, ponieważ oprócz rzeczywistej wartości model przewidywał w jego przypadku aż 6 innych gatunków muzycznych

  • hip-hop, jeżeli był mylnie klasyfikowany, to tylko jako reggae.

Dzięki temu, że nie usunąłem ze zbioru zmiennej określającej tytuł piosenki mogę teraz dokładniej przyjrzeć się poszczególnym błędom. Najbardziej przykuł moją uwagę jeden utwór jazzowy, który w Model1 został pomylony z utworem metalowym.

track.name genre .pred_class
Inner Urge - Rudy Van Gelder Edition; 2004 Digital Remaster jazz metal

Po jego wysłuchaniu staje się zrozumiałe, dlaczego model pomylił się w tym przypadku. Jest to utwór szybki, agresywny i ponury i mimo że jazzowy, to ma on w sobie elementy metalu. Model2 nie popełnił już tego błędu; można podejrzewać, że stało się to za sprawą przywrócenia w zbiorze zmiennej acousticness - jest ona bowiem cechą, która zdecydowanie różni pomiędzy sobą te gatunki i najprawdopodobniej informacje, które ze sobą wniosła do Model2 wyeliminowały tę pomyłkę.

Inną pomyłką, którą można wziąć pod lupę jest utwór jazz sklasyfikowany jako country w Model2.

track.name genre .pred_class
The Sidewinder - Remastered 1999/Rudy Van Gelder Edition jazz country

Po jego wysłuchaniu również można zrozumieć pomyłkę. Jest to zdecydowanie utwór jazzowy, jednak brzmi on też jednocześnie trochę jak “piosenka z westernu”.

Są tu też obecne pomyłki, dla których nie ma żadnego logicznego wyjaśnienia, jak sklasyfikowanie utworu Linkin Park “What I’ve Done” jako country, czy kompozycji Vivaldi’ego jako reggae.

track.name genre .pred_class
What I've Done metal country
track.name genre .pred_class
Recomposed By Max Richter: Vivaldi, The Four Seasons: Spring 1 - 2012 classical reggae

Wykresy krzywych ROC

Code
rf_wflow %>% 
  last_fit(split) %>% 
  collect_predictions() %>% 
  roc_curve(truth = genre, .pred_blues, .pred_classical, .pred_country, .pred_folk, .pred_hiphop, .pred_jazz, .pred_metal, .pred_reggae, .pred_rock) %>% 
  autoplot() + labs(title = "Model1") +
  theme(plot.title = element_text(hjust = 0.5))
rf_wflow2 %>% 
  last_fit(split2) %>% 
  collect_predictions() %>% 
  roc_curve(truth = genre, .pred_blues, .pred_classical, .pred_country, .pred_folk, .pred_hiphop, .pred_jazz, .pred_metal, .pred_reggae, .pred_rock) %>% 
  autoplot() + labs(title = "Model2") +
  theme(plot.title = element_text(hjust = 0.5))

Z krzywych ROC stworzonych za pomocą metody one vs. all dla każdego gatunku można znaleźć potwierdzenie tego, co można było zobaczyć już na macierzy konfuzji - model najlepiej radził sobie z przewidywaniem metalu i hiphopu, natomiast najgorzej radził sobie z klasyfikacją bluesa, rocka i country.

Istotność cech w modelu

Code
f_imp <- 
  rf_wflow %>% 
  last_fit(split) %>% 
  extract_fit_parsnip() %>% 
  vip(num_features = 13)

f_imp2 <- 
  rf_wflow2 %>% 
  last_fit(split2) %>% 
  extract_fit_parsnip() %>% 
  vip(num_features = 15)

Z powyższego wykresu można zauważyć, że najważniejszą cechą przy podziałach stosowanych w tworzeniu optymalnego modelu lasu losowego Model1 było energy, danceability oraz danceability. W Modelu2 najważniejsza jest natomiast decade, a w drugiej kolejności acousticness, które na podstawie wysokiej korelacji zostało usunięte w pierwszym modelu. Widać więc, że korelację tą należało w tym przypadku zignorować, ponieważ nie dość że model Model2 charakteryzował się lepszą dokładnością, to okazuje się, że wysoko skorelowana z inną zmienna acousticness w znacznym stopniu przyczyniła się do tworzenia podziałów w lasie losowym, który okazał się najlepszym modelem.

W obu przypadkach najmniej przydały się natomiast zmienne key, mode oraz time_signature, co nie jest zaskoczeniem, ponieważ gatunki nigdy nie ogarniczają się do konkretnych skal ani struktur.

Z powyższego wykresu można wyciągnąć jeszcze jeden ważny wniosek: ponieważ dekada okazuje się istotną zmienną, można przypuszczać, że stworzone modele nie sprawdziłyby się dobrze w klasyfikacji utworów spoza zbioru, na podstawie którego został zbudowany. Utwory w zbiorze nie były bowiem wybierane losowo – sam dobierałem playlisty, z których pochodzą. Często ograniczały się one do konkretnych dekad, o czym świadczą ich nazwy, takie jak “10s Metal Classics”. W związku z tym nie można zakładać, że ilość utworów z danego gatunku w zbiorze jest reprezentatywna dla całego gatunku. Z tego powodu model prawdopodobnie nie byłby równie skuteczny, gdyby został przetestowany na zupełnie innym zbiorze testowym, ponieważ zmyliłyby go daty wydania, do których nie mógłby odnieść zasad, które tak dobrze działały w kontekście utworów z mojego zbioru.

Podsumowanie

W ramach projektu dokonałem skutecznej klasyfikacji gatunków muzycznych na podstawie metadanych z serwisu Spotify. Proces rozpoczął się od pobrania danych przez API serwisu Spotify, które następnie poddałem eksploracyjnej analizie w celu zidentyfikowania kluczowych cech wpływających na klasyfikację. Dane zostały odpowiednio przygotowane, a następnie na ich podstawie zbudowałem i przetestowałem różne modele uczenia maszynowego. Najlepszy model osiągnął dokładność 87.1% na zbiorze testowym. Projekt potwierdził, że metadane Spotify mogą efektywnie wspierać klasyfikację gatunków muzycznych, jednak najprawdopodobniej nie będą one aż tak skuteczne w przewidywaniu gatunków utworów spoza zbioru, który stworzyłem. Można jednak mieć nadzieję, że przy pobraniu dużo większej ilości danych w sposób bardziej losowy, można by było stworzyć model, który dawałby znacznie lepsze rezultaty na nieznanych obserwacjach.

Żródła